import Foundation

class DiffManager {
  /// Compare two collection of items and generate a `Changes` object that can be used
  /// to update the data source. It supports insertions, deletions, updates, reloads,
  /// child updates and moving items around.
  ///
  /// - Parameters:
  ///   - oldModels: The old collection of items.
  ///   - newModels: The new collection of items.
  /// - Returns: If both a the same, then it returns `nil`, otherwise it returns a `Changes` struct.
  public func compare(oldItems: [Item], newItems: [Item]) -> Changes? {
    guard let itemDiffs = generateItemDiffs(oldItems: oldItems, newItems: newItems) else {
      return nil
    }

    let changes = Changes(itemDiffs: itemDiffs)

    return changes
  }

  /// Compute changes for when there are less new items then there are old ones.
  ///
  /// - Parameters:
  ///   - newItems: New collection of items.
  ///   - oldItems: Old collection of items, the count exceeds the new collection of items.
  ///   - changes: The collection of `ItemDiff`'s, this gets modified when a change is discovered.
  fileprivate func processLessNewItems(_ newItems: [Item], _ oldItems: [Item]) -> [ItemDiff] {
    var changes = [ItemDiff]()

    for (index, oldItem) in oldItems.enumerated() {
      if index > newItems.count - 1 {
        changes.append(.removed)
        continue
      }

      let itemDiff = diff(oldModel: oldItem, newModel: newItems[index])

      if let index = newItems.index(where: { $0.compareItemIncludingIndex(oldItem) }), oldItem.index != index {
        changes.append(.move(oldItem.index, index))
      } else {
        changes.append(itemDiff)
      }
    }

    return changes
  }

  /// Compute changes for when there are more new items then there are old ones.
  ///
  /// - Parameters:
  ///   - newItems: New collection of items, the count exceeds the old collection of items.
  ///   - oldItems: Old collection of items.
  ///   - changes: The collection of `ItemDiff`'s, this gets modified when a change is discovered.
  fileprivate func processMoreNewItems(_ newItems: [Item], _ oldItems: [Item]) -> [ItemDiff] {
    var changes = [ItemDiff]()

    for (index, newItem) in newItems.enumerated() {
      if index > oldItems.count - 1 {
        changes.append(.new)
        continue
      }

      let oldItem = oldItems[index]
      let itemDiff = diff(oldModel: oldItem, newModel: newItem)

      if let index = newItems.index(where: { $0.compareItemIncludingIndex(oldItem) }), oldItem.index != index {
        changes.append(.move(oldItem.index, index))
      } else {
        changes.append(itemDiff)
      }
    }

    return changes
  }

  /// Compute changes for collection of items that have the same amount of items.
  ///
  /// - Parameters:
  ///   - newItems: New collection of items.
  ///   - oldItems: Old collection of items.
  ///   - changes: The collection of `ItemDiff`'s, this gets modified when a change is discovered.
  fileprivate func processEqualAmountOfItems(_ newItems: [Item], _ oldItems: [Item]) -> [ItemDiff] {
    var changes = [ItemDiff]()

    for (index, newItem) in newItems.enumerated() {
      let oldItem = oldItems[index]
      let itemDiff = diff(oldModel: oldItem, newModel: newItem)

      if let index = newItems.index(where: { $0.compareItemIncludingIndex(oldItem) }), oldItem.index != index {
        changes.append(.move(oldItem.index, index))
      } else {
        changes.append(itemDiff)
      }
    }

    return changes
  }

  /// Iterate over two collection of items and determine which operations are appropriate.
  /// If changes are detected in the collection then the method will return a collection
  /// of item diffs. These are generated by `diff` located below.
  ///
  /// - Parameters:
  ///   - oldModels: The old collection of items.
  ///   - newItems: The new collection of items.
  /// - Returns: Will return `nil` if no changes are detected, otherwise a collection of `ItemDiff`s
  private func generateItemDiffs(oldItems: [Item], newItems: [Item]) -> [ItemDiff]? {
    guard oldItems !== newItems else {
      return nil
    }

    if oldItems.count > newItems.count {
      return processLessNewItems(newItems, oldItems)
    } else if newItems.count > oldItems.count {
      return processMoreNewItems(newItems, oldItems)
    } else {
      return processEqualAmountOfItems(newItems, oldItems)
    }
  }

  /// Compare two items and prioritize the update.
  /// When used inside a component reload operation, kind would generate a reload operation
  /// as it would need a new instance of the view. Kind is a direct reference to the view
  /// identifiers. On screen information updates like title, subtitle and text can be applied
  /// as a soft or hard update. Soft update maps the values to the view that is on screen without
  /// telling the data source to reload. Hard updates need to invoke reload on the data source as
  /// that usually means that the size of the item has changed.
  ///
  /// - Parameters:
  ///   - oldItem: The old item
  ///   - newItem: The new item
  /// - Returns: An item diff depending on which attribute changed.
  private func diff(oldModel: Item, newModel: Item) -> ItemDiff {
    // Indicates that the view identifier changed, this will later lead to the view
    // being reloaded.
    if newModel.kind != oldModel.kind {
      return .kind
    }

    // The items unique identifier has changed which means that the item cannot match.
    if newModel.identifier != oldModel.identifier {
      return .identifier
    }

    if newModel.title != oldModel.title {
      return .title
    }

    if newModel.subtitle != oldModel.subtitle {
      return .subtitle
    }

    if newModel.text != oldModel.text {
      return .text
    }

    if newModel.size != oldModel.size {
      return .size
    }

    if let newModel = newModel.model {
      if let oldModel = oldModel.model {
        if !(newModel.equal(to: oldModel)) {
          return .model
        }
      } else {
        return .model
      }
    } else if oldModel.model != nil {
      return .model
    }

    if newModel.image != oldModel.image {
      return .image
    }

    if newModel.action != oldModel.action {
      return .action
    }

    if !(newModel.meta as NSDictionary).isEqual(to: oldModel.meta) {
      return .meta
    }

    if !(newModel.relations as NSDictionary).isEqual(to: oldModel.relations) {
      return .relations
    }

    return .none
  }
}
